Add nulls_distinct support to UniqueTogetherValidator#9866
Add nulls_distinct support to UniqueTogetherValidator#9866mag123c wants to merge 2 commits intoencode:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds support for Django 5.0's nulls_distinct parameter to the UniqueTogetherValidator class in Django REST Framework. When nulls_distinct=False is set on a UniqueConstraint, the validator now properly treats NULL values as equal for uniqueness checks, preventing integrity errors on databases like Oracle where NULLs can violate unique constraints.
Changes:
- Modified
get_unique_together_constraints()to extract and yield thenulls_distinctattribute from constraints - Updated
UniqueTogetherValidatorto accept and handle thenulls_distinctparameter in validation logic - Added comprehensive tests for the new functionality with Django 5.0+ version guards
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| rest_framework/serializers.py | Updated get_unique_together_constraints() to yield nulls_distinct from constraints and modified call sites to unpack the additional tuple element |
| rest_framework/validators.py | Added nulls_distinct parameter to UniqueTogetherValidator, updated validation logic to skip NULL checks when nulls_distinct=False, and modified __repr__() and __eq__() methods |
| tests/test_validators.py | Added test model with nulls_distinct=False constraint and comprehensive test cases for create operations and validator equality |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| class TestUniqueConstraintNullsDistinct(TestCase): | ||
| """ | ||
| Tests for UniqueConstraint with nulls_distinct=False option. | ||
| When nulls_distinct=False, NULL values should be treated as equal | ||
| for uniqueness validation. | ||
| """ | ||
|
|
||
| def setUp(self): | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel | ||
|
|
||
| class UniqueConstraintNullsDistinctSerializer(serializers.ModelSerializer): | ||
| class Meta: | ||
| model = UniqueConstraintNullsDistinctModel | ||
| fields = ('name', 'code', 'category') | ||
|
|
||
| self.serializer_class = UniqueConstraintNullsDistinctSerializer | ||
|
|
||
| def test_nulls_distinct_false_validates_null_as_duplicate(self): | ||
| """ | ||
| When nulls_distinct=False, creating a second record with NULL values | ||
| in the constrained fields should fail validation. | ||
| """ | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel | ||
|
|
||
| # Create first record with NULL values | ||
| UniqueConstraintNullsDistinctModel.objects.create( | ||
| name='First', | ||
| code=None, | ||
| category=None | ||
| ) | ||
|
|
||
| # Attempt to create second record with same NULL values | ||
| serializer = self.serializer_class(data={ | ||
| 'name': 'Second', | ||
| 'code': None, | ||
| 'category': None | ||
| }) | ||
|
|
||
| # Should fail validation because nulls_distinct=False | ||
| assert not serializer.is_valid() | ||
|
|
||
| def test_nulls_distinct_false_allows_different_non_null_values(self): | ||
| """ | ||
| Non-NULL values should still work normally with uniqueness validation. | ||
| """ | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel | ||
|
|
||
| # Create first record with non-NULL values | ||
| UniqueConstraintNullsDistinctModel.objects.create( | ||
| name='First', | ||
| code='A', | ||
| category='X' | ||
| ) | ||
|
|
||
| # Create second record with different values - should pass | ||
| serializer = self.serializer_class(data={ | ||
| 'name': 'Second', | ||
| 'code': 'B', | ||
| 'category': 'Y' | ||
| }) | ||
| assert serializer.is_valid(), serializer.errors | ||
|
|
||
| def test_nulls_distinct_false_rejects_duplicate_non_null_values(self): | ||
| """ | ||
| Duplicate non-NULL values should still fail validation. | ||
| """ | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel | ||
|
|
||
| # Create first record | ||
| UniqueConstraintNullsDistinctModel.objects.create( | ||
| name='First', | ||
| code='A', | ||
| category='X' | ||
| ) | ||
|
|
||
| # Attempt to create duplicate - should fail | ||
| serializer = self.serializer_class(data={ | ||
| 'name': 'Second', | ||
| 'code': 'A', | ||
| 'category': 'X' | ||
| }) | ||
| assert not serializer.is_valid() | ||
|
|
||
| def test_unique_together_validator_nulls_distinct_equality(self): | ||
| """ | ||
| Test that UniqueTogetherValidator equality considers nulls_distinct. | ||
| """ | ||
| mock_queryset = MagicMock() | ||
| validator1 = UniqueTogetherValidator( | ||
| queryset=mock_queryset, | ||
| fields=('a', 'b'), | ||
| nulls_distinct=False | ||
| ) | ||
| validator2 = UniqueTogetherValidator( | ||
| queryset=mock_queryset, | ||
| fields=('a', 'b'), | ||
| nulls_distinct=False | ||
| ) | ||
| validator3 = UniqueTogetherValidator( | ||
| queryset=mock_queryset, | ||
| fields=('a', 'b'), | ||
| nulls_distinct=True | ||
| ) | ||
|
|
||
| assert validator1 == validator2 | ||
| assert validator1 != validator3 |
There was a problem hiding this comment.
The test coverage for nulls_distinct=False is missing some important edge cases. Consider adding tests for:
- Update scenarios where an instance is being modified with NULL values
- Partial NULL scenarios (e.g., one field NULL, another non-NULL like
code=None, category='X') - Mixed update scenarios (updating from non-NULL to NULL values)
These scenarios would help ensure the validator correctly handles the interaction between nulls_distinct=False and the update logic in UniqueTogetherValidator.__call__().
| """ | ||
|
|
||
| def setUp(self): | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel |
There was a problem hiding this comment.
The module 'tests.test_validators' imports itself.
| When nulls_distinct=False, creating a second record with NULL values | ||
| in the constrained fields should fail validation. | ||
| """ | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel |
There was a problem hiding this comment.
The module 'tests.test_validators' imports itself.
| """ | ||
| Non-NULL values should still work normally with uniqueness validation. | ||
| """ | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel |
There was a problem hiding this comment.
The module 'tests.test_validators' imports itself.
| """ | ||
| Duplicate non-NULL values should still fail validation. | ||
| """ | ||
| from tests.test_validators import UniqueConstraintNullsDistinctModel |
There was a problem hiding this comment.
The module 'tests.test_validators' imports itself.
Fixes #8409
This PR adds support for Django 5.0's
nulls_distinctoption inUniqueTogetherValidator.Problem
When
nulls_distinct=Falseis set on aUniqueConstraint, DRF's validator still skips validation for NULL values, causing integrity errors on databases like Oracle where NULLs can violate unique constraints.Solution
nulls_distinctfromUniqueConstraintinget_unique_together_constraints()UniqueTogetherValidator.__init__nulls_distinct=False, validate NULL values as potential duplicatesChanges
rest_framework/serializers.py: Updatedget_unique_together_constraints()to yieldnulls_distinctrest_framework/validators.py: Addednulls_distinctparameter toUniqueTogetherValidatortests/test_validators.py: Added tests fornulls_distinct=Falsebehavior (Django 5.0+)Backward Compatibility
nulls_distinct=None(default): Existing behavior preserved (skip NULL validation)getattr()fallback, no breaking changes